Skip to content

feat(cli): cloudhal deploy --release installs the signed release (#227) - #238

Merged
Madraka merged 3 commits into
mainfrom
claude/deploy-release-227
Nov 15, 2026
Merged

Madraka merged 3 commits into
mainfrom
claude/deploy-release-227

Conversation

@Madraka

@Madraka Madraka commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

cloudhal deploy --release (#227 part 2), as RFC-0046 §11b specifies, with the §9 amendments from #226. It installs the signed release of the CLI's own version instead of building the project.

The code is in packages/cli/src/commands/deploy/release/, plus the key table in packages/cli/src/config-build-keys.ts.

Order (nothing written outside .cloudhal/deploy/, and nothing uploaded, before the attestation passes)

  1. Refusals before anything is fetched (project-unsupported, release-unavailable):
    • another installation;
    • several sites;
    • Workers Builds;
    • a version ≤ 0.8.0.
  2. Trust lists (trust.ts).
    • keys.json and yanked.json, each with its .sig, are fetched on every run from TRUST_BASE_URL, or read from --from <dir>. Any failure is trust-unavailable, with no fallback to a release asset or a cache.
    • Both are verified with PINNED_ROOTS and must be unexpired.
    • The persisted YankMemory lives in ~/.cloudhal/release-trust.json, written atomically with mode 0600. It is checked by verifyKeys and verifyYanked, and is stored only after both pass. A damaged memory file is refused, never dropped.
    • Before the ceremony PINNED_ROOTS is empty, so the command refuses with a message naming the owner's root ceremony (docs/security/release-signing-keys.md › First-time setup). That is tested, and nothing is fetched.
  3. Release lookup (fetch-release.ts). GET api.github.com/repos/cloudhal-dev/cloudhal/releases/tags/v<v>:
    • a 404 (which covers drafts, since GitHub doesn't show them to anyone else), draft: true, or a missing bundle asset is release-unavailable, and the message says to deploy without --release;
    • otherwise the statement, signature, manifest and Sigstore bundle go into a fresh .cloudhal/deploy/release-download/<v>/, with byte caps from LIMITS, streamed to disk.
  4. Checks on that manifest, before the tarball is downloaded:
    • The theme check (theme-check.ts):
      • it compares src/, public/ and skills/, plus astro.config.mjs, tsconfig.json and jsconfig.json, with this CLI's own starter (assetDir("starters")/blog through starterFilter, as cloudhal init copies it);
      • it also finds every PostCSS config, and any postcss key in a package.json, from the project up to Vite's workspace root;
      • files are compared after CRLF→LF, skipping .DS_Store, Thumbs.db and desktop.ini; the refusal lists the first 20 differences;
      • --discard-local-theme lists the files that are not deployed and changes nothing on disk.
    • The config build keys (config-build-keys.ts):
      • every ProjectConfig key, plus media, has a class: deploy, runtime, build or refused; analytics, comments and media are classed per sub-key;
      • the table's type is mapped over ProjectConfig, so tsc fails on an unclassed key;
      • comments.available other than true, or media.transform other than none (unless the manifest's features.mediaTransformVar is true), is refused, and so is an unknown key or sub-key; --discard-local-theme does not cover these.
    • Wrangler: the installed version must be at least manifest.toolchain.wrangler (wrangler-too-old names the required version).
  5. The tarball, then verifyRelease: keys = mergeTrust(PINNED_KEYS, { keys, yanked }) and yanked, so a revoked key gives key-revoked and a yanked version gives yanked. planVersion is 1.
  6. Attestation (@cloudhal/release-verify/attestation):
    • failed → attestation-failed;
    • unavailable → attestation-unavailable, with how to install gh 2.68+ and run gh auth login;
    • --allow-single-factor continues on the release key alone and prints a line saying so. The flag is never implied.
  7. Extraction (extract.ts) into .cloudhal/deploy/release/<v>/{site,admin}/{modules,assets}:
    • the directory is removed and recreated empty each time;
    • every target is path.resolved and must stay inside;
    • files are created with wx, so a duplicate fails;
    • only entries the verifier yields are written, and _headers comes from the manifest.
  8. Configs (configs.ts):
    • the same siteWorkerConfig and adminWorkerConfig as today, plus main (the extracted main module), base_dir, no_bundle, find_additional_modules, preserve_file_names, and one rules entry per module type (exact globs, fallthrough: false);
    • the compatibility date and flags come from the manifest, and assets is the extracted directory with an ASSETS binding plus the manifest's routing config;
    • they are written to .cloudhal/deploy/release-config/{site,admin}/wrangler.jsonc.
  9. Then the same steps as every deploy:
    • provisioning, secrets, settings, both Workers (-c with the release configs), state, the first push and the purge;
    • the downgrade guard takes manifest.levels.schedules;
    • --release --dry-run stops before wrangler.

Refactors (no behaviour change for cloudhal deploy)

  • buildSite is split: siteConfigFor writes the config, and buildSite still builds.
  • shipWorkers takes an optional site config.
  • The shared post-ship tail of deploy() moved to deploy/finish.ts.
  • downgradeGuard takes { adminUrl, token, localLevel? }.
  • OWN_KEYS is exported.

CLI and docs

  • Flags --release, --from (with --release), --allow-single-factor and --discard-local-theme. The last three are refused without --release. help.ts is updated.
  • docs/start/installation.md gains Installing the signed release.
  • AGENTS.md describes the module and carries the Astro/Vite upgrade step. RFC §11b points at the list.
  • Changeset: @cloudhal/cli minor.

Review round 1

  • CI: typecheck in every consumer. @cloudhal/release-verify now typechecks under @types/node alone, which is what create-cloudhal uses, as well as under the Workers types:

    • WebCrypto gets ArrayBuffer-backed views, copied only when a buffer is shared;
    • DecompressionStream is typed through explicitly;
    • the reader's result type is inferred rather than named.

    turbo run typecheck passes for every package.

  • CodeQL. The test's URL checks parse the URL and compare hostname exactly. src has no substring URL checks.

  • No version argument. deploy --release 0.9.0 is refused, and the message points to npx @cloudhal/cli@<version> deploy --release.

  • --discard-local-theme lists every file that is not deployed. The 20-file truncation stays for the refusal.

  • Order of operations. The trust lists, the lookup, the checks, verification, the attestation and extraction all run before loadDeploy. So no analytics prompt, cloudhal.json write or store open happens until the release has verified. There is a test for this.

  • Yank memory. The file is re-read just before the atomic rename and merged monotonically (mergeMemory):

    • for the same key: max serial, newer keysIssuedAt, and the union of versions, revoked keys and retired yank keys;
    • across a rotation: the newer key list's memory, keeping every retired key.

    A failure to save is its own code, trust-state, and names the file and the cause.

  • Attestation. A timeout, a signal, a rate limit or a network error is worded as "could not be checked … still a refusal". Only a real verification failure says the files were not built by bundle-build.yml.

  • GitHub API. GH_TOKEN, else GITHUB_TOKEN, goes to api.github.com only, and a test checks that no other request carries it. A 403 or 429 is its own code, github-rate-limited, and names GH_TOKEN.

  • skills/. An existing skills/ is refused even when empty, and a file named skills is refused too.

  • Docs. docs/start/installation.md lists every check and refusal, in order. packages/cli/README.md has a --release entry.

  • Drift guards.

    • The Site Worker's CLOUDHAL_SITE_ID override is added. The choice is now singleSiteId in astro-renderer/src/site-dispatch.ts, which the cloudflare entry uses, and test/site-dispatch.test.ts checks that the var wins over the built id.
    • "The cloudflare target ignores dataDir" is not small. The integration puts dataDir into the virtual config on every target, so the fix is to stop that for cloudflare and prove it on the workerd suites. That is follow-up release: drift guard that the cloudflare target ignores dataDir (RFC-0046 §11b) #239.
  • @cloudhal/release-verify package and cloudhal deploy --release (RFC-0046) #227's done-when needs @cloudhal/release-verify published, which it is not yet, so this PR now says Refs @cloudhal/release-verify package and cloudhal deploy --release (RFC-0046) #227 instead of closing it.

  • Changeset. Now also @cloudhal/release-verify patch and @cloudhal/astro-renderer patch.

⚠️ For the release

@cloudhal/cli now depends on @cloudhal/release-verify (workspace:*). That package is not on npm yet: the owner's placeholder step, then trusted publishing. So the next release must publish release-verify along with the CLI. If publish-ci still leaves it out, the published CLI's install would fail.

Tests (no network: every URL is served by a recording fake; run one file at a time)

  • deploy-release.test.ts (15). It uses the synthetic signed bundle from release-verify's tests and a throwaway root and yank key signed with scripts/release-keys.ts. It covers:

    • refusal before the ceremony, naming it, with no fetch;
    • trust lists down, refused with no fallback;
    • the memory stored, and a replayed serial-1 list then refused;
    • a key revoked in the yank list (key-revoked) and a yanked version (yanked);
    • ≤0.8.0 refused before any fetch; a missing release, a draft and no bundle each refused before any download;
    • a tampered tarball (tarball-mismatch), with nothing extracted;
    • a failed attestation; an unavailable one, then with --allow-single-factor;
    • a dry run that extracts exactly the listed files, writes the configs and runs no wrangler;
    • a full deploy, where both Workers get -c with the release configs;
    • --from with no fetch, and --from without a valid yanked.json;
    • the project refusals (installation, sites, Workers Builds);
    • the theme refused before the tarball, and --discard-local-theme listing the files not deployed;
    • build keys refused with and without the flag (comments.available: false, media.transform: cdn-cgi, an unknown key);
    • an old wrangler.
  • release-theme-check.test.ts (7). These are the RFC's list:

    • an unmodified starter passes, also with CRLF;
    • edited src/, added public/, a deleted starter file, an edited astro.config.mjs and an added skills/x/SKILL.md are each named;
    • an edited tsconfig.json, jsconfig.json, postcss.config.mjs, .postcssrc.json and a postcss key are each named;
    • inside a pnpm workspace, the root postcss.config.cjs and a postcss key in between are found;
    • a config above the workspace root, or above a standalone project, passes;
    • .DS_Store, Thumbs.db, desktop.ini, vite.config.ts, .env, README.md, .gitignore and wrangler.jsonc pass;
    • an older starter's changed file is named.
  • config-build-keys.test.ts (6), the key-table guards:

    • init's config, and deploy and runtime keys, pass;
    • build keys are refused, with the feature switch for media.transform;
    • unknown keys and sub-keys are refused;
    • every key in OWN_KEYS is classed;
    • every project.<key> in the starter's astro.config.mjs is classed;
    • the integration's option sub-keys are classed (tsc);
    • prepareAdminWorker with different mail, ai, browser, observability, placement, domain and siteId writes a byte-identical entry, skills and design module; only the wrangler config differs.
  • release-extract.test.ts (2): a fresh directory each time, _headers written, and ../ and duplicate entries refused.

  • Existing suites, all passing:

    Suite Tests
    deploy.test 6
    deploy-attach 1
    multi-site-deploy 5
    deploy-workers-builds 3
    search-deploy 3
    cli-commands 4
    release-keys / release-trust-assets / release-workflows 20 / 4 / 14
  • Checks: cli tsc, biome on the changed files, and size:check are clean, and the docs suite passes (41).

Not in this PR

Two drift guards that §11b lists outside the CLI:

  • a Site Worker test serving a site through CLOUDHAL_SITE_ID that differs from the baked id;
  • an astro-renderer test that the cloudflare target ignores dataDir.

The first is now added (review round 1); the second is #239.

Refs #227 (its done-when needs @cloudhal/release-verify published; follow-up #239)

🤖 Generated with Claude Code

RFC-0046 §11b with the §9 amendments: instead of building, install this CLI
version's signed release.

- The signed key and yank lists, from cloudhal.dev on every run or from
  --from, verified with the pinned root and checked against the CLI's yank
  memory (~/.cloudhal/release-trust.json); no fallback, no override. Before
  the root ceremony no root is pinned: refused, naming the ceremony.
- The release: published (not a draft) with a bundle, v0.9.0 or newer;
  verifyRelease with the keys reduced by both lists and yanks applied; the
  build attestation (--allow-single-factor when gh is unavailable).
- Before the tarball: the theme check against the CLI's starter (PostCSS up
  to Vite's workspace root; --discard-local-theme), the cloudhal.json build
  keys (config-build-keys.ts, a table mapped over ProjectConfig), and the
  bundle's minimum wrangler. Installations, multi-site and Workers Builds are
  refused.
- Path-safe extraction into a fresh .cloudhal/deploy/release/<v>/ and no_bundle
  wrangler configs that upload exactly those bytes; then the usual
  provisioning, secrets, downgrade guard (manifest level), deploy and push.
- Refactors: siteConfigFor, shipWorkers' site config, deploy/finish.ts,
  downgradeGuard's options object, OWN_KEYS exported.
- Tests use release-verify's synthetic signed bundle and no network.

Closes #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka and others added 2 commits November 15, 2026 09:31
- release-verify typechecks under every consumer's lib settings (create-cloudhal:
  @types/node only): ArrayBuffer-backed WebCrypto inputs, the gzip pipe typed
  through, the reader's result type inferred.
- deploy --release refuses a version argument (npx @cloudhal/cli@<v>).
- --discard-local-theme lists every file not deployed.
- Steps 1-6 (trust, lookup, checks, verify, attestation, extract) run before
  loadDeploy: no prompt, cloudhal.json write or store open before then.
- The yank memory is re-read and merged monotonically before the rename; a
  failed save is trust-state, naming the file.
- An attestation that could not finish (timeout, signal, rate limit, network)
  says so; still a refusal.
- GH_TOKEN/GITHUB_TOKEN for the API lookup only; 403/429 is
  github-rate-limited.
- An existing skills/ (even empty) is a theme difference.
- Docs: every check and refusal in installation.md; a README entry.
- Drift guard: astro-renderer's singleSiteId, CLOUDHAL_SITE_ID over the built
  id (test). The dataDir guard is #239.
- Tests: URL checks compare hostnames (CodeQL).

Refs #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
An expired or revoked GH_TOKEN/GITHUB_TOKEN gets 401 even on public
endpoints, which fell through to release-unavailable. On a 401 the lookup now
retries once without the header; when that succeeds it goes on with a
one-line warning, otherwise the retry's own status decides.

Refs #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka merged commit 3eced25 into main Nov 15, 2026
11 checks passed
@Madraka
Madraka deleted the claude/deploy-release-227 branch November 15, 2026 06:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants